Headless C2 workflow (Docker, no AppImage)
Headless C2 workflow (Docker, no AppImage)
This guide is for running the packaged backend (kikobot-backend from the .deb) inside Docker, controlling the arm without RViz or Kikobot Studio, and executing trajectories with move_joints.py.
Two different “ports” (do not confuse them)
| What | Typical value | Purpose |
|---|---|---|
| Robot PC IP | e.g. 192.168.1.100 | Your laptop/container reaches the backend on this address |
| WebSocket port | 8080 | JSON API: status, firmware start, motion (optional if you only use ROS CLI) |
| USB serial device | /dev/ttyUSB0 or /dev/ttyACM0 | Physical arm — UART to servos (not an IP) |
- “Connect using IP/port” for software →
ws://<ROBOT_PC_IP>:8080until you get aconnectedmessage (see step 1B). - “Connect the arm” for hardware → USB cable + correct
/dev/tty…+start_firmware(see step 1A).
Overview (your intended workflow)
[1A] USB arm visible in container
[1B] Optional: WebSocket handshake on ws://IP:8080 → “connected”
[2] Clean old processes → KIKOBOT_HEADLESS=1 → kikobot-backend c2 → port 8080 listening
[3] Start firmware (real arm only)
[4] Same ROS domain as backend (shipped .deb: **0**) → move_joints.py run waypoint_2.json
ROS domain: The shipped backend does not use domain 33. If ROS_DOMAIN_ID is unset, everything is on domain 0 — same as move_joints.py’s default. Do not export ROS_DOMAIN_ID=33 in only one terminal.
Step 0 — One-time: Docker + .deb
- Image with ROS 2 Humble and
kikobot-backendinstalled (/opt/kikobot,kikobot-backendonPATH). - For a real arm, run the container with the serial device, for example:
docker run -it --device=/dev/ttyUSB0:/dev/ttyUSB0 ... ros2-humble-workspace
# or docker compose with devices: ["/dev/ttyUSB0:/dev/ttyUSB0"]
Inside the container, confirm the device:
ls -l /dev/ttyUSB* /dev/ttyACM* 2>/dev/null
Step 1 — Connect the arm (until successful)
1A — Hardware link (USB / serial)
- Power the C2 controller and plug USB into the same machine that runs Docker (or pass the device into the container as above).
- Find the port:
ls -l /dev/serial/by-id/ 2>/dev/null
# or: python3 /opt/kikobot/share/c2/kikobot_uart_with_gripper_loop.py --list-ports
- You will use this path in step 3 as
PORT(e.g./dev/ttyUSB0).
Success criteria: device node exists and is readable (test -r /dev/ttyUSB0).
1B — Software link (IP + WebSocket 8080) — recommended check
Use this to confirm the backend is reachable before motion. From your integration PC or a second shell in the container:
pip install 'websockets>=12' # once
# Same machine as backend:
python3 /opt/kikobot/share/examples/kikobot_ws_client_example.py --host 127.0.0.1 status
# Remote machine (robot PC IP):
python3 kikobot_ws_client_example.py --host 192.168.1.100 status
Success criteria: JSON with "type": "connected" and robot_profile: "c2", then firmware_status / pong without connection errors.
If this fails, fix step 2 before running waypoints.
Note: If the backend is only inside Docker and you connect from the host, publish port 8080 (-p 8080:8080) or use host network mode, and use 127.0.0.1 or the container/host IP consistently.
Step 2 — Start Docker, then headless backend
2A — Clean up a previous run (required in Docker)
Avoid address already in use on 8080:
pkill -f websocket_bridge.py 2>/dev/null || true
pkill -f MainActionServer 2>/dev/null || true
pkill -f 'ros2 launch urdf_v3' 2>/dev/null || true
sleep 2
rm -f /tmp/kikobot_backend_c2.lock
fuser -k 8080/tcp 2>/dev/null || true
ss -ltn | grep 8080 || echo "8080 is free"
2B — Start backend (headless)
In one dedicated terminal (keep it open):
source /opt/ros/humble/setup.bash
source /opt/kikobot/ros/install/setup.bash
export KIKOBOT_HEADLESS=1
# Shipped .deb: ROS_DOMAIN_ID is usually unset → DDS domain **0** (ROS 2 default).
# Only set ROS_DOMAIN_ID if you set the same value in every shell (backend + move_joints).
kikobot-backend c2
Success criteria:
- Log line:
WebSocket bridge listening on port 8080 - No
websocket_bridge exited during startup - Optional:
ss -ltn | grep 8080
Headless means no RViz; the Simulation node and motion planner still run so trajectories can execute in sim (or on hardware after firmware).
Step 3 — Start firmware (real arm only)
Skip in pure simulation if you only need RViz-less sim motion.
With backend running, in a second shell:
Option A — WebSocket (no AppImage):
python3 -c "
import asyncio, json, websockets
async def main():
async with websockets.connect('ws://127.0.0.1:8080') as ws:
print(await ws.recv()) # connected
await ws.send(json.dumps({'type':'start_firmware','port':'/dev/ttyUSB0'}))
print(await ws.recv())
asyncio.run(main())
"
Option B — Run firmware script directly (if your image documents it):
cd /opt/kikobot/share/c2
python3 kikobot_uart_with_gripper_loop.py -p /dev/ttyUSB0
Success criteria: firmware_status → running: true, or firmware log without immediate exit (/tmp/kikobot_firmware.log).
Step 4 — Run waypoint trajectory (move_joints.py)
Use a second docker exec shell. Same container as the backend.
source /opt/ros/humble/setup.bash
source /opt/kikobot/ros/install/setup.bash
# Must match the backend. Shipped backend uses domain **0** unless you exported ROS_DOMAIN_ID.
# Quick check in the backend terminal: echo "$ROS_DOMAIN_ID" (empty = 0)
export ROS_DOMAIN_ID="${ROS_DOMAIN_ID:-0}"
# If move_joints.py is on your workspace mount:
cd /workspace # or wherever waypoint_2.json lives
python3 move_joints.py joints # quick check: live joint state
python3 move_joints.py run waypoint_2.json --sync
--sync: aligns the planner with the first pose before a long sequence (recommended).
Success criteria: [OK] motion complete per segment; in sim, joint values change (check ros2 topic echo /joint_states --once).
If move_joints.py is not in the container
Copy from your dev tree into the container mount, or call motion via WebSocket motion_plan (see WEBSOCKET_INTEGRATION_GUIDE.pdf).
Best practices checklist
| # | Practice |
|---|---|
| 1 | One kikobot-backend c2 per container; stop with Ctrl+C, not by closing the terminal only |
| 2 | Run cleanup (2A) before every fresh backend start in long-lived Docker containers |
| 3 | ROS_DOMAIN_ID must match backend and move_joints (shipped .deb → 0 / unset) |
| 4 | Distinguish 8080 (TCP/WebSocket) from /dev/ttyUSB0 (arm) |
| 5 | Verify 8080 with kikobot_ws_client_example.py status before blaming waypoints |
| 6 | Real arm: firmware running before expecting hardware motion |
| 7 | Waypoint JSON must respect C2 joint limits; do not reuse files from other robot models |
| 8 | Logs: /tmp/kikobot_websocket.log, /tmp/kikobot_c2_display.log |
Quick reference — environment variables
| Variable | Headless Docker suggestion |
|---|---|
KIKOBOT_HEADLESS=1 | No RViz |
ROS_DOMAIN_ID | Omit or 0 for shipped .deb; set the same value in backend + move_joints if you override |
KIKOBOT_ROBOT_MODEL=c2 | Set by kikobot-backend c2 |
Troubleshooting
| Symptom | Action |
|---|---|
8080 address already in use | Step 2A cleanup |
context is invalid on WS motion | Restart backend after cleanup |
move_joints cannot see joints / action server | Wrong ROS_DOMAIN_ID; backend not running |
| Motion only worked with RViz before | Old bridge/planner zombie; headless is fine after clean start |
| Arm does not move on hardware | Firmware not started; USB not in container; wrong tty |
Related docs
WEBSOCKET_INTEGRATION_GUIDE.pdf— full WebSocket API (ws://IP:8080)/opt/kikobot/CLIENT_INSTALL.txt— install.debexamples/kikobot_ws_client_example.py— connection test client